<?php

/**
 * 后台进程
 * 
 * 仅适用于Linux系统，必须以root用户启动
 * 
 * 必须在CLI下启动，php xxx.php start
 * 
 * start 启动，stop 停止，status 查看状态
 */
class Daemon {

    const DLOG_TO_CONSOLE = 1;
    const DLOG_NOTICE = 2;
    const DLOG_WARNING = 4;
    const DLOG_ERROR = 8;
    const DLOG_CRITICAL = 16;

    /**
     * User ID
     * 设定进程以什么用户运行（用户的uid），一般可以设定为php-fpm的运行账户
     * 
     * @var int
     */
    public $userID = 65534; // nobody

    /**
     * Group ID
     * 设定进程以什么用户运行（用户的uid），一般可以设定为php-fpm的运行账户
     * 
     * @var integer
     */
    public $groupID = 65533; // nobody

    /**
     * Terminate daemon when set identity failure
     *
     * @var bool
     */
    public $requireSetIdentity = false;

    /**
     * Daemon flag
     * 
     * @var string 
     */
    public $flag = 'daemon';

    /**
     * Path to log file
     * 
     * @var string 
     */
    public $logFileLocation = '/tmp/daemon.log';

    /**
     * Path to PID file
     *
     * @var string
     */
    public $pidFileLocation = '/tmp/daemon.pid';

    /**
     * Path of stoping daemon
     *
     * @var type 
     */
    public $stopFileLocation = '/tmp/daemon.st';

    /**
     * processLocation
     * 进程信息记录目录
     *
     * @var string
     */
    public $processLocation = '/tmp/proc';

    /**
     * processHeartLocation
     * 进程心跳包文件
     *
     * @var string
     */
    public $processHeartLocation = '';

    /**
     * Home path
     *
     * @var string
     */
    public $homePath = '/';

    /**
     * Current process ID
     *
     * @var int
     */
    private $_pid = 0;

    /**
     * Is this process a children
     *
     * @var boolean
     */
    private $_isChildren = false;

    /**
     * Task
     * 
     * @var array
     */
    private $_task = array();

    /**
     * 
     * @param string $flag 任务标识
     * @param int $uid 指定运行该任务的用户，一般可以设定为php-fpm的运行账户，默认root
     * @param int $gid 指定运行该任务的用户组，一般可以设定为php-fpm的运行账户，默认root
     * @param str $dir 任务相关文件存储路径，默认/tmp
     */
    public function __construct($flag = 'daemon', $uid = 0, $gid = 0, $dir = '/tmp') {

        $this->flag = $flag;
        $this->userID = $uid;
        $this->groupID = $gid;
        $this->logFileLocation = $dir . '/' . $flag . '.log';
        $this->pidFileLocation = $dir . '/' . $flag . '.pid';
        $this->stopFileLocation = $dir . '/' . $flag . '.st';
        $this->processLocation = $dir . '/proc';

        register_shutdown_function(array(&$this, 'releaseDaemon'));
    }

    /**
     * 设置任务
     * 
     * @param int $interval 执行间隔
     * @param callable $callable 可被调用的函数
     * @param boolean $sequential 是否顺序执行
     * @return boolean
     */
    public function setTask($interval = 1, $callable = null, $sequential = true) {

        if ($interval < 1) {
            $this->_logMessage("Bad interval，min 1 second", self::DLOG_TO_CONSOLE);
            return false;
        }

        if (!is_callable($callable)) {
            $this->_logMessage("Not callable", self::DLOG_TO_CONSOLE);
            return false;
        }

        $this->_task = array('callable' => $callable, 'interval' => $interval, 'sequential' => $sequential, 'run_time' => time() + $interval);
    }

    public function run() {

        $argv = $_SERVER['argv'];

        if (!isset($argv[1])) {
            $this->_logMessage("Usable arguments are start/stop/status", self::DLOG_TO_CONSOLE);
            return false;
        }

        if ($argv[1] == 'start') {
            $this->start();
        }

        if ($argv[1] == 'stop') {
            $this->stop();
        }

        if ($argv[1] == 'status') {
            $this->status();
        }
    }

    /**
     * 启动进程
     *
     * @return bool
     */
    public function start() {

        if (!(PHP_SAPI == 'cli')) {
            $this->_logMessage("Cound not running daemon in not cli environment", self::DLOG_TO_CONSOLE);
            return false;
        }

        $this->_logMessage("Starting daemon {$this->flag} ...", self::DLOG_TO_CONSOLE);

        if (!$this->_daemonize()) {

            return false;
        }

        while (!$this->_isStopping()) {
            // 一直执行
        }
    }

    /**
     * 停止进程
     *
     * @return void
     */
    public function stop() {

        if (!(PHP_SAPI == 'cli')) {
            $this->_logMessage("Cound not running daemon in not cli environment", self::DLOG_TO_CONSOLE);
            return false;
        }

        if (!$fp = fopen($this->stopFileLocation, 'w')) {
            $this->_logMessage("Could not write to stop file, Check problem and kill process with kill -9 pid", self::DLOG_TO_CONSOLE);
            return false;
        } else {
            $this->_logMessage("Stopping daemon ...", self::DLOG_TO_CONSOLE);

            if (is_file($this->pidFileLocation)) {

                $pid = file_get_contents($this->pidFileLocation);

                // 写入stop文件，以便在_tick()中判断是否要停止并设置_isRunning为false
                fputs($fp, $pid);
                fclose($fp);

                $i = 0;

                /*
                 * stop调用后，_isStopping() 被 _tick()设置为true
                 * 然后start()里的死循环跳出，程序结束
                 * releaseDaemon()被调用，删除pid文件
                 */
                while (is_file($this->pidFileLocation) && $i < 4) {
                    $i++;
                    clearstatcache();
                    usleep(500);
                }

                if (is_file($this->pidFileLocation)) {
                    $this->_logMessage("Could not stop daemon, Check problem and kill process with kill -9 {$pid}", self::DLOG_TO_CONSOLE);
                    return false;
                }
            } else {

                fclose($fp);
            }

            /*
             * pid文件被删除，说明daemon已停止，清理残留的stop文件
             */
            unlink($this->stopFileLocation);

            $this->_logMessage("Daemon stopped", self::DLOG_TO_CONSOLE);

            return true;
        }
    }

    public function status() {
        if ($this->_isDaemonRunning()) {
            echo 'Daemon already running with PID: ' . file_get_contents($this->pidFileLocation);
        } else {
            echo 'Daemon stopped';
        }
        echo PHP_EOL;
    }

    private function _daemonize() {

        if (ob_get_level() > 0) {
            ob_end_flush();
        }

        chdir($this->homePath); // 切换到根目录，以便通过umask设定当前进程创建的文件和目录的默认权限
        umask(0); // 设定目录777权限，文件666权限

        if (!file_exists($this->processLocation)) {
            @mkdir($this->processLocation, 0777, true);
        }

        if ($this->_isDaemonRunning()) {
            // Daemon is already running. Exiting
            return false;
        }

        if (!$this->_fork()) {
            // Coudn't fork. Exiting.
            return false;
        }

        if (!$this->_setIdentity() && $this->requireSetIdentity) {
            // Required identity set failed. Exiting
            return false;
        }

        if (!posix_setsid()) {
            $this->_logMessage('Could not make the current process a session leader', self::DLOG_ERROR);

            return false;
        }

        if (!$fp = fopen($this->pidFileLocation, 'w')) {
            $this->_logMessage('Could not write to PID file ' . $this->pidFileLocation, self::DLOG_ERROR);
            return false;
        } else {
            fputs($fp, $this->_pid);
            fclose($fp);
        }

        // 写入监控日志
        $this->writeProcess();

        declare(ticks=1); // 每执行一条低级语句触发一次信号处理

        pcntl_signal(SIGCHLD, array(&$this, 'sigHandler'));
        pcntl_signal(SIGTERM, array(&$this, 'sigHandler'));
        pcntl_signal(SIGTERM, array(&$this, 'sigHandler'));

        pcntl_signal(SIGALRM, array(&$this, 'sigHandler')); // 注册时钟信号处理程序

        pcntl_alarm(1); // 1秒间隔发送时钟信号

        $this->_logMessage("Daemon {$this->flag} running with PID: " . $this->_pid, self::DLOG_TO_CONSOLE | self::DLOG_NOTICE);

        return true;
    }

    /**
     * Signals handler
     *
     */
    public function sigHandler($sigNo) {

        switch ($sigNo) {

            case SIGALRM:   // 时钟信号

                $this->_tick();
                break;

            case SIGTERM:   // Shutdown
                $this->_logMessage('Shutdown signal');
                exit();
                break;

            case SIGCHLD:   // Halt
                $this->_logMessage('Halt signal');
                while (pcntl_waitpid(-1, $status, WNOHANG) > 0);
                break;
        }
    }

    private function _tick() {

        if ($this->_isStopping()) {
            return;
        }

        if (!$this->_writeHeart()) {
            $this->_logMessage("Can not write heart", self::DLOG_ERROR);
        }

        pcntl_alarm(1); // 1秒间隔发送时钟信号

        if (time() >= $this->_task['run_time']) {

            if ($this->_task['sequential']) { // 顺序执行，下一个任务必须等待上一个任务完成，并等待一个间隔时间
                call_user_func($this->_task['callable']); // 执行任务
                $this->_task['run_time'] = time() + $this->_task['interval'];
            } else { // 顺序执行，下一个任务无需等待上一个任务完成，完全按间隔时间执行
                $this->_task['run_time'] = time() + $this->_task['interval'];
                call_user_func($this->_task['callable']); // 执行任务
            }
        }
    }

    private function _isStopping() {
        clearstatcache();
        if (is_file($this->stopFileLocation)) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Releases daemon pid file
     * This method is called on exit (destructor like)
     *
     * @return void
     */
    public function releaseDaemon() {
        if ($this->_isChildren && is_file($this->pidFileLocation)) {
            $error = error_get_last();
            if ($error) {
                $err_msg = $error['message'] . "\n" . $error['file'] . ": " . $error['line'];
                $this->_logMessage('Releasing daemon with error: ' . $err_msg, self::DLOG_ERROR);
            } else {
                $this->_logMessage('Releasing daemon');
            }
            unlink($this->pidFileLocation);
        }
    }

    /**
     * Check is daemon already running
     *
     * @return bool
     */
    private function _isDaemonRunning() {
        if (is_file($this->pidFileLocation)) {
            $oldPid = file_get_contents($this->pidFileLocation);

            if ($oldPid !== false && posix_kill(trim($oldPid), 0)) {

                $this->_logMessage('Daemon already running with PID: ' . $oldPid, (self::DLOG_TO_CONSOLE | self::DLOG_ERROR));

                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

    /**
     * Forks process
     *
     * @return bool
     */
    private function _fork() {

        $this->_logMessage('Forking...');

        $pid = pcntl_fork();

        if ($pid == -1) {
            // 出错
            $this->_logMessage('Could not fork', self::DLOG_ERROR);

            return false;
        } elseif ($pid) {
            // 父进程
            $this->_logMessage('Killing parent');

            exit();
        } else {
            // fork的子进程
            $this->_isChildren = true;
            $this->_pid = posix_getpid();

            return true;
        }
    }

    /**
     * Sets identity of a daemon and returns result
     *
     * @return bool
     */
    private function _setIdentity() {

        if (!posix_setgid($this->groupID) || !posix_setuid($this->userID)) {
            $this->_logMessage('Could not set identity', self::DLOG_WARNING);

            return false;
        } else {
            return true;
        }
    }

    /**
     * writeProcess
     * 将当前进程信息写入监控日志，另外的脚本会扫描监控日志的数据发送信号，如果没有响应则重启进程
     *
     * @return void
     */
    public function writeProcess() {

        // 初始化 proc
        $this->_initProcessLocation();

        $command = trim(implode(' ', $_SERVER['argv']));

        // 指定进程的目录
        $processDir = $this->processLocation . '/' . $this->_pid;
        $processCmdFile = $processDir . '/cmd';
        $processPwdFile = $processDir . '/pwd';

        // 所有进程所在的目录
        if (!is_dir($this->processLocation)) {
            @mkdir($this->processLocation, 0777, true);
            @chmod($processDir, 0777);
        }

        // 查询重复的进程记录
        $pDirObject = dir($this->processLocation);
        while ($pDirObject && (($pid = $pDirObject->read()) !== false)) {
            if ($pid == '.' || $pid == '..' || intval($pid) != $pid) {
                continue;
            }

            $pDir = $this->processLocation . '/' . $pid;
            $pCmdFile = $pDir . '/cmd';
            $pPwdFile = $pDir . '/pwd';
            $pHeartFile = $pDir . '/heart';

            // 根据cmd检查启动相同参数的进程
            if (is_file($pCmdFile) && trim(file_get_contents($pCmdFile)) == $command) {
                is_file($pCmdFile) && unlink($pCmdFile);
                is_file($pPwdFile) && unlink($pPwdFile);
                is_file($pHeartFile) && unlink($pHeartFile);

                // 删目录有缓存
                usleep(1000);

                rmdir($pDir);
            }
        }

        // 新进程目录
        if (!is_dir($processDir)) {
            @mkdir($processDir, 0777);
            @chmod($processDir, 0777);
        }

        // 写入命令参数
        file_put_contents($processCmdFile, $command);
        file_put_contents($processPwdFile, $_SERVER['PWD']);

        // 写文件有缓存
        usleep(1000);

        return true;
    }

    /**
     * 写入心跳
     * 
     */
    private function _writeHeart() {

        return file_put_contents($this->processHeartLocation, time());
    }

    /**
     * _initProcessLocation
     * 初始化
     *
     * @return void
     */
    private function _initProcessLocation() {
        $this->processHeartLocation = $this->processLocation . '/' . $this->_pid . '/heart';
    }

    /**
     * 记录日志
     * 
     * @param string 消息
     * @param integer 级别
     * @return void
     */
    private function _logMessage($msg, $level = self::DLOG_NOTICE) {
        if ($level & self::DLOG_TO_CONSOLE) {
            echo $msg . PHP_EOL;
        }

        if ($fp = fopen($this->logFileLocation, 'a')) {
            fputs($fp, date('Y-m-d H:i:s') . ': ' . $msg . PHP_EOL);
            fclose($fp);
        }
    }

}
